UnityEditorでScriptableObjectをListに仕舞うと楽しいがUndo対応でハマった【後、よくした】


概要

ScriptableObjectをList内に突っ込んだ場合の挙動とUndo、Redoの組み合わせがキッツイ状態を作り出す。

最小構成作ってみて遊んでみよう、と思ってハマった。


成果物がこれ。

UndoSample

https://github.com/sassembla/UndoSample



要素

ScriptableObjectを適当なScriptからCreate、Listに仕舞った場合と、そうでない場合の挙動の差があってハマった。


・ControllerScriptからScriptableObjectをCreate、初期値をセット

・Registerにその内容をセット、Undo/Redoを可能にする

・UndoでScriptableObjectが消える

・RedoでScriptableObjectが復活する


のだけど、


例えばControllerScript側で次のようなコードを書いていたとする。



ControlScript.cs

List<RecordObject> contents;


private void CreateContents () {

contents = new List<RecordObject>();


{

var newRecord = ScriptableObject.CreateInstance<RecordObject>();

newRecord.data = 100;

Undo.RegisterCreatedObjectUndo(newRecord, "Create RecordObject:" + newRecord.GetInstanceID());

contents.Add(newRecord);

}

}


CreateContentsメソッドが呼ばれたタイミングで、

・RecordObjectのリストを作成して新規作成

・作成したRecordObjectのdataパラメータに100をセット

・UndoスタックにCreateしたことを記録

・RecordObjectをcontentsに追加


んで、対象となるScriptableObjectを継承したRecordObjectはこんな感じ。



RecordObject.cs

public class RecordObject : ScriptableObject {

public int data;

}


で、Undo(command + Z)を行うとどうなるか。

作成されたRecordObjectは破棄される。

OnDisabledとかメソッドを持っていたらしっかりと呼ばれる。


RecordObject.cs

public class RecordObject : ScriptableObject {

public int data;


private void OnDisable () {

data = 1;

}

}


例えば data を 1 にする、とかやれば、この場でdataが1になったあとにDisableされる。はず。


で、ここで、List<RecordObject> contents に入っているオブジェクトはどうなるのか?っていうと、

影響を受けない。

nullにならない。

勿論dataは1にならない。

DisableされたあとDestroyされてるのでnullになるのでは? と思ったのだが、Listに直接入れた場合はそうはならない。

ちなみにListに入れずに、インスタンスを一枚噛ませて保持しようとすると、その場合ScriptableObjectがUndo = Destroy後にnullになった。


そのケースはListのケースの説明が終わってから書く。


Listに直接入っているScriptableObjectは大元がUndoで消されたとしても影響を受けない

Destroyしても元気に活動を続ける ControlScript.cs の List<RecordObject> contents 内のScriptableObject。

例えば ScriptableObject .GetInstanceID メソッドを実行しても、nullではないので元気にIDを返してくる。


そこで、Redoするとどうなるのか?


ScriptableObjectのCreateのUndo -> Redoは、ちゃんとScriptableObjectのIDもRedoしてくれる。

つまりUndoで消したScriptableObjectと同じInstanceIDが、Redoで作り出されたScriptableObjectにも振られる、と。


Create from ScriptController -> この時のInstanceIDが-100

Undo -> 消える

Redo -> InstanceIDが-100で戻る


ところで、手元に残っているListにも、、なんだ、、まだ元気な、、nullになってないScriptableObjectのインスタンスがあってさ、、、

同じID -100 を返してきたりする。 うーん。


Listに突っ込むことで参照が消えなくなってるみたいなのがあるのかなあ。

とりあえずScriptableObjectはひたすらいろいろ書く羽目になって辛いのでできるだけ特定の用途以外の内容を書かない方向で済ませたい。


でこれでListに直接入ってるケースは終了。


んで、



Listに入れず、所持オブジェクトを適当に一個作ってそれを管理する場合

以下のような構成にすると、ScriptableObjectは、すべての箇所で、Undoのタイミングできちんとnullになる。



ControlScript.cs

List<Content> contents;


private void CreateContents () {

contents = new List<Content>();

contents.Add(new Content());

}


contentsは変わらずListなんだけど中身が下記。


Content.cs

public class Content {

public readonly string contentId;


public readonly int recordObjId;

public RecordObject recordObj;


RecordObjectを持ったインスタンス。

とすると、


List<RecordObject> contents  -> List<Content> contents に変わっただけ、、に見えるんだけど、

ScriptableObjectであるRecordObject recordObj  がUndoでnullになるタイミングで、

Contentの保持しているrecordObjもnullになる。


Listに直接入ってるScriptableObjectは影響を受けないのに、

Listに入ってるContentが保持してるScriptableObjectは影響を受けることができる。


ああややこしい。

Listに直接ScriptableObjectしまうとなんかなるのはもうなんだ、そんなことしねーよバーーカ!!みたいな気持ちで見なかったことにした。


おかげで都合のいいUndo/Redoを実装することができた。

ここであげたコードを含むリポジトリを下記に用意した。

UndoSample

https://github.com/sassembla/UndoSample



追記

ScriptableObjectでUndo/Redo管理するのいいよね?って思いつつ、「この辺きもいな」って疑念が消えなかったんだが、

考え直して綺麗にすることができた。


re-考え方

ScriptableObjectをRecordとして分散して持っていたのだが、よう考えたら「上位に一個だけ持って内容をRecord可能にすればいいのでは?」という気持ちになった。

で、


・ScriptableObjectをWindowのOnEnableで作る

・その下に全データを構築する

・Recordしたい要素だけSerializeFieldをつける

という手で行けた。


こうすることで、上位の一つのRecordだけをUndo/Redo対象にセットできる。

んで、問題として残るのは、

・new -> Undo -> newしたのが消える -> Redo -> newされて消されてたやつがnewされるがその際にはデフォルトのコンストラクタが使われる

というあたり。


これに関しては Undo.undoRedoPerformed で対象の監視とかをやってRedo後になんとかする、みたいな感じ。

汚い。


あと、依然としてScriptableObjectがUnityEditorのコンテキスト仕切り直しタイミングで影響を受けてnullになることがあるので、相変わらずそのへんだけ対処する必要がある。まあはい。